昨天收尾時我說,接下來要拿真的年報來跑,不能再靠查規格過關。
今天就是那一天。老實講,我原本規劃的這篇是一篇「語意」文章:OCR 已經把字讀出來了,接著討論「關係人交易」這種術語怎麼理解、金額跟比率怎麼對。結果第一輪測試下去,連字都讀不出來。這篇後來變成一篇踩坑紀錄,但我覺得它比我原本想寫的那篇更有用。
Day 14 到 17 需要一家公司當貫穿的範例。條件是財報結構清楚、有重大訊息、XBRL 資料齊全。
我選台積電(2330)。資料公開程度在台股裡數一數二,年報、財報、重大訊息、XBRL 都找得到。PDF 是原生文字層,不是掃描檔,所以我可以直接用 PyMuPDF 抽出 ground truth,不用自己打字。還有一個比較私人的理由:它的爭議性低,文章不會被公司本身的話題帶走。
下載的過程就踩了兩個坑。
MOPS 新版的年報查詢頁是 Angular 寫的 SPA,直接抓只會拿到一個空殼。我改從台積電投資人關係網站拿同一份公開揭露文件,內容跟正式申報一致,只是入口不同。
然後我抓錯語言了。第一次拿到的是英文版年報,差一點就拿去用了。這系列做的是繁中 OCR,拿英文版當素材,整個 Phase 4 的前提就是錯的。換成繁體中文版:92 頁,9.2MB,確認有文字層。
Tip:下載公開文件後,第一件事是用 PyMuPDF 抽一頁文字出來看。抽得出來,代表是原生 PDF,有現成的 ground truth;抽出來是空的,代表是掃描檔,你得另外準備答案卷。這一步花十秒鐘,會決定你後面整套評估怎麼設計。
我原本以為「重大契約摘要」會是一份排版過的 PDF,需要 OCR 才讀得到。
實際打開 MOPS 的重大訊息公告,它是結構化的 HTML 表單。欄位名稱、日期、內容全部是機器可讀的文字。從一開始就沒有 OCR 的問題。
這件事讓我有點洩氣,但它改變了要測什麼。規劃書把「重大契約摘要」跟「年報」並列成 OCR 的目標,但它們的處境完全不同。重大訊息是原生結構化資料,年報才是排版過的 PDF。真正需要 OCR 的,是年報裡那些引用公告、引用裁罰、引用財報的段落。
第二個發現更直接。我在年報裡搜「重要契約」,找到的是這句(PDF 第 62 頁的文字層):
本公司目前並無簽訂重要契約。另本公司於財務報告亦揭露「重大或有負債及未認列之合約承諾」,請至公開資訊觀測站參閱本公司合併財務報告。
也就是說,我原本想拿來測「重大契約摘要」的那塊素材,在這份年報裡根本不存在。有契約承諾的部分,年報叫你去 MOPS 看合併財報。
我在那一頁前後翻了一陣子,最後決定不換公司。一份真實的年報就是長這樣:它會把東西指到別的文件去。這種「指出去」的引用,正好是 Day 15 要處理的交叉引用問題。硬換一家剛好有重要契約的公司,反而會讓範例變得太乾淨。
在跑測試之前,我先把「語意不對」可能發生的地方整理了一下。這份分類是從閱讀年報跟重大訊息的格式整理出來的,還不是從測試結果統計出來的。
| 類型 | 例子 | 為什麼 OCR 對了還是可能錯 |
|---|---|---|
| 同一個詞,不同文件指不同東西 | 「重大訊息」在 MOPS 是法定公告類別,在一般文章裡只是形容詞 | 抽取時如果只看字面,會把一段新聞稿式的描述當成法定公告 |
| 關係人交易 | 交易對象常以簡稱、全名、英文名交替出現 | 字都讀對了,但系統不知道「台積公司」跟「台灣積體電路製造股份有限公司」是同一個主體 |
| 董事會決議 | 一段文字裡常同時有決議日、公告日、生效日 | 日期讀對了,但掛錯欄位,時間線就整個錯 |
| 敘事文字夾數字 | 「營收年增35.9%」「為534個客戶生產1萬2,682種不同產品」 | 數字跟單位、跟它描述的對象,是靠句子結構綁在一起的 |
| 民國紀年與國字數字 | 「民國一百一十一年七月二十五日」 | 少一個「百」字,年份就從 111 變成 11,差了一百年 |
| 公文字號 | 「竹環字第1140012798號」 | 十位數字錯一碼,字號還是長得很合理,但指到另一份公文 |
最後兩類我原本放在表的最下面,覺得是小問題。跑完測試之後,它們變成了今天的主角。
92 頁 PDF 裡用關鍵字掃一輪(重大契約、土地、附註、資本支出、合併財務),挑了 4 頁:
第 61 頁不是重大契約,但它的格式跟我要處理的「公告字號引用」完全對應。三筆罰鍰金額又很接近(40 萬、45 萬、45 萬),是很好的誤判測試。讀錯一碼,數字看起來照樣合理。
用 Day 1 到 10 建好的 call_vision() 呼叫模式,把 4 張渲染好的整頁圖送給 GB10 上的 gemma-4-26b,max_tokens 設 3000。
| 頁 | CER | 覆蓋率 | finish_reason | 備註 |
|---|---|---|---|---|
| 5 | 2.0454 | 0.2404 | length | 撞上限,複讀迴圈 |
| 9 | 1.0151 | 0.1705 | length | 撞上限 |
| 44 | 0.9854 | 0.0417 | length | 幾乎沒抓到內容 |
| 61 | 0.9792 | 0.0318 | length | 幾乎沒抓到內容 |
每一頁都跑了將近一分鐘(約 58 到 61 秒),completion 都是整整 3000 個 token。decode 速度其實很正常,每秒 49.5 到 51.6 個 token,跟 Day 3 量到的 48.5 差不多。模型一直在寫,只是寫的都不是原文。
第 61 頁最慘。三個公文字號加三筆罰鍰,0/6 命中。模型輸出的開頭是:
民國一十四年及前一十三年比較,對於
業務量與利潤之變化,本公司有持續進行
分析,以利未來規劃。
原頁根本沒有這段話。這是模型自己編的,編完就開始吐空的表格列,| | | 一行接一行,吐了七百多行,直到撞上 token 上限。
第 5 頁也好不到哪去。原文第一句是「民國一百一十四年對台積公司而言又是強勁成長的一年」,模型寫成「民國一百四十年對於電腦而言是充滿希望的一年」,然後同一句「我們相信台積電的技術,特別是與客戶密切合作」重複了幾十次。
我盯著這幾份輸出看了一陣子,心裡已經開始寫結論了:「gemma-4-26b 無法處理繁中財報密集頁」。
這個結論很戲劇化,也很符合我原本對「密集表格很難」的預期。我差點就這樣寫了。
幸好我照這系列的規矩,先把結果交給獨立的 verifier 核對,沒有直接採信。
verifier 做了三件事。第一,親眼看過 4 張圖,確認渲染沒壞。第二,逐字讀完模型輸出,確認幻覺跟複讀是真的。第三件是我完全沒想到的:它去看了圖的尺寸跟 prompt_tokens。
這份年報是對開排版。PyMuPDF 的一頁,其實是印刷上的兩頁拼在一起,渲染出來是 3386×2205 的寬幅圖。而 gemma-4-26b 的視覺編碼用固定的 256 個 image token,不管圖多大都一樣。四次請求的 prompt_tokens 全部是 292,這個數字就是證據:圖的大小完全沒有反映在 token 數上。
兩頁密密麻麻的小字,被壓進 256 個視覺 token。模型看得到版面骨架,所以它抓得到印刷頁碼、章節編號這種大字;但內文小字糊成一片,它只好靠語言習慣去「猜」一段合理的財報文字,猜到卡住就開始複讀。
verifier 的說法很準:問題出在輸入解析度,模型的 OCR 能力其實根本沒被測到。CER 逼近 1、0/6 命中、複讀,都是同一個原因的下游結果,不是三個獨立的證據。
我想老實記一筆。我準備寫的那個結論是錯的。我沒有造假,數字都是真的,錯的是我對數字的解讀,而那個解讀剛好是我原本就相信的。這種錯最難自己發現,因為它不會讓你覺得哪裡怪。
照 verifier 的建議,把 4 張圖從中間垂直切開。ground truth 也跟著切:用 PyMuPDF 的 get_text("text", clip=...) 依座標切,然後驗證左右兩半的字數加起來等於原頁全長。不是用字元數硬對半。
8 張半頁圖,各打一次真實請求:
| 頁 | 半邊 | CER | 覆蓋率 | finish_reason |
|---|---|---|---|---|
| 5 | 左 | 3.6758 | 0.1599 | length |
| 5 | 右 | 4.8462 | 0.3345 | length |
| 9 | 左 | 0.8909 | 0.2626 | stop |
| 9 | 右 | 1.8453 | 0.0669 | length |
| 44 | 左 | 1.0777 | 0.5259 | stop |
| 44 | 右 | 0.8585 | 0.2663 | stop |
| 61 | 左 | 0.9313 | 0.1405 | length |
| 61 | 右 | 0.3816 | 0.7510 | stop |
切半不是乾淨的修復。8 個半頁的平均 CER 是 1.8134,比原本 4 個整頁的平均 1.2563 還差。第 5 頁兩半、第 9 頁右半,切完照樣撞上限複讀。
只有本來密度就不高的半頁,例如第 44 頁兩半、第 9 頁左半、第 61 頁右半,才因為切半而正常結束。
我最在意的第 61 頁左半,三個公文字號都在這一半,切完還是 0/6。這半頁本身還塞了 1238 個字。更糟的是,它內部其實還分兩欄,模型把兩欄的句子一行一行交錯拼在一起:
| 民國一百十四年可持續損失。然而,台積公司並無 |
| 展望來,台積公司將持續以員工體驗為軸心,聚焦以 |
| 同因素劃歸入受損失。然而,本公司有助於規劃檢查 |
第一行的前半是左欄,第二行「展望來,台積公司將持續以員工體驗為軸心」其實是右欄的內容。每一行單獨看都像中文,合起來完全不通。
所以病因要講精確一點:文字密度對上固定的視覺 token 預算。頁面大小只是表象。切半只是把總字數砍一半,密度沒降到門檻以下的區塊,照樣崩。這個結論比「切開就好」麻煩很多。它代表 Day 18 的 Region Routing 不能只把頁面切成固定大小的格子,得先判斷一塊區域有多密,太密的要再裁小,或換更高的解析度重送。
這是今天的實作項目。上面兩輪測試留下的輸出裡,我挑出「會造成語意錯誤」的案例,整理成一份可以持續往下加的案例集。
挑選標準只有一條:這個錯誤如果沒被抓到,下游讀這份資料的人會不會做出錯的判斷。純粹的排版雜訊(多一個 Markdown 分隔線之類)不收。
| 編號 | 頁/區塊 | 原文(GT) | 模型輸出 | 錯誤型態 | 語意後果 |
|---|---|---|---|---|---|
| M-01 | 44 左半 | 民國一百一十一年七月二十五日/3,065,000股 | 民國一十一年十二月十五日/3,065,000股 | 國字數字漏「百」、月日整個換掉 | 股數對了,日期錯了一百年又換了月份。這種錯最危險,因為數字那一半會讓人以為整格都對 |
| M-02 | 44 左半 | 發行日期:民國一百一十二年三月一日 | 發行日:民國一十一年三月一日 | 年份錯 | 發行日變得比申報生效日還早,時間線顛倒 |
| M-03 | 44 左半 | 申報生效日期及總股數 | 申請生效日及施前取得 | 欄位名稱改寫 | 「申報」跟「申請」在法規上是不同程序,欄位名錯了,值就掛錯欄位 |
| M-04 | 44 左半 | 員工限制權利新股之既得條件 | 限制員工權利股新發行之取得條件 | 專有名詞被換成常見詞 | 「既得」是股份給付的專門用語,「取得」是一般動詞,意思不一樣 |
| M-05 | 5 整頁 | 民國一百一十四年對台積公司而言又是強勁成長的一年 | 民國一百四十年對於電腦而言是充滿希望的一年 | 年份錯、主詞被換掉、整句改寫 | 114 年變 140 年;主詞從公司變成「電腦」 |
| M-06 | 9 整頁、左右半 | 305種製程技術,為534個客戶生產1萬2,682種不同產品 | 三份輸出裡都找不到 305、534、12,682 | 數字整批遺漏 | 敘事段落的關鍵數字消失,但前後文照樣通順 |
| M-07 | 61 整頁 | 三筆公文字號與罰鍰 | 「民國一十四年及前一十三年比較…」+七百多行空表格列 | 幻覺+複讀 | 0/6 命中;輸出看起來像一段財報分析 |
| M-08 | 61 左半 | 左右兩欄各自成段 | 兩欄逐行交錯拼接 | 欄位交錯 | 每一行都像中文,合起來不通;抽取器會把右欄的主詞配到左欄的事件 |
M-01 我看了很多遍。3,065,000 股一字不差,旁邊的日期卻錯得離譜。如果評估指標只看數字有沒有出現,這一格會拿滿分。
這也是這篇標題想講的事。OCR 認得字,不等於懂欄位。一個欄位是「名稱+值+它屬於哪一列」的組合,其中一個錯了,整格就是錯的,而且錯得很安靜。
案例集要能持續累積,所以每一筆用固定格式存。這是我目前用的欄位設計:
from dataclasses import dataclass, asdict
import json
@dataclass
class MisreadCase:
case_id: str # M-01, M-02 ...
source_pdf: str # experiments/mops/tsmc_2025_annual_report_zh.pdf
pdf_page: int # PyMuPDF 頁索引(對開,一頁含兩個印刷頁)
region: str # full / left / right / tight
gt_text: str # 從文字層切出的原文,逐字
ocr_text: str # 模型原始輸出,逐字,不修
error_type: str # date_numeral / field_label / term_swap / omission / hallucination / column_interleave
semantic_impact: str # 一句話:沒抓到的話,讀的人會錯在哪
raw_output_path: str # 指回落檔的 model_output.txt
case = MisreadCase(
case_id="M-01",
source_pdf="experiments/mops/tsmc_2025_annual_report_zh.pdf",
pdf_page=44,
region="left",
gt_text="民國一百一十一年七月二十五日/3,065,000股",
ocr_text="民國一十一年十二月十五日/3,065,000股",
error_type="date_numeral",
semantic_impact="股數正確但申報生效日錯誤,時間線與法定程序判斷都會錯",
raw_output_path="experiments/mops/raw_responses_split/page_44_restricted_stock_left.model_output.txt",
)
with open("misread_cases.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(asdict(case), ensure_ascii=False) + "\n")
gt_text 跟 ocr_text 規定逐字,不准整理。我一開始想把 OCR 輸出裡的 Markdown 表格符號去掉,讓案例好讀一點,後來作罷。案例集的用途是之後拿來測驗證規則,被整理過的輸出會讓規則看起來比實際更有效。
raw_output_path 也是一樣的道理。每一筆都要能指回原始落檔,不然半年後我自己也不記得這筆是從哪次測試來的。
規劃書特別提到「同音字誤判對金額、比率的影響」。我得老實說,這批輸出裡沒有出現典型的同音字錯誤。
我特地搜過幾組我原本預期會出現的,例如「權利」寫成「權力」、「截至」寫成「截止」。原文裡「限制員工權利新股」「截至年報刊印日止」都有,但八份半頁輸出、四份整頁輸出裡都沒找到這兩個錯字。
所以下面這段是推論,不是實測。
視覺模型做 OCR,理論上是看字形,不是聽字音,不應該有同音字的問題。但從 M-04 那筆「既得」變「取得」可以看出來,當圖太糊、模型看不清楚的時候,它會退回語言模型的本能,寫出一個「在這個位置最常見的詞」。這時候就可能出現類似同音字的錯:寫出的字語意通順、字形不像,因為它根本不是從字形來的。
放到金額跟比率上,最可怕的是那些讀起來都合理的替換。這幾個是我設計驗證規則時會拿來測的範例,都不是這次跑出來的:
這些錯誤有一個共同點:字元層級的指標幾乎抓不到。CER 只會告訴你錯了一兩個字,不會告訴你那一個字讓年份差了一百年。
| 項目 | 狀態 | 備註 |
|---|---|---|
| 台積電年報繁中版為原生文字層 PDF,92 頁,9.2MB | 已查證 | PyMuPDF 直接讀出文字 |
| MOPS 重大訊息是 HTML 表單,不是 PDF | 已查證 | 實際打開公告頁確認 |
| 年報 PDF 第 62 頁「本公司目前並無簽訂重要契約」 | 已查證 | PDF 文字層原文 |
| 4 頁整頁 OCR 的 CER、覆蓋率、finish_reason | 已查證 | 真實請求,verifier 逐字核對 |
| 對開排版+固定 256 image token 造成看不清 | 已查證 | verifier 對照 prompt_tokens 恆為 292 推出 |
| 切半後 8 張的 CER、finish_reason | 已查證 | 座標切 GT,字數驗證無損 |
| 第 61 頁左半切半後仍 0/6 | 已查證 | 逐字核對 |
| 案例集 M-01 到 M-08 的原文與輸出 | 已查證 | 直接取自 experiments/mops/ 下的落檔 |
| 同音字對金額、比率的影響 | 未實測 | 這批輸出沒出現,段落內容是推論與設計用範例 |
| 提高 DPI 或裁更小能不能解決 | 未查證 | 今天只測了切半 |
最後這一格讓我有點坐不住。如果連字號都讀不出來,Day 15 原本要做的「附註引用公告字號」解析就沒有輸入可用。語意邏輯寫得再漂亮,吃進去的是七百行空表格也沒用。
所以明天得先把第 61 頁那三個字號讀出來,解析器往後排。裁更小、解析度拉高,看看密度降下來之後模型能不能讀對。